feat: Add session$destroy() to remove all module reactivity#4372
Conversation
Port of posit-dev/py-shiny#2209 — adds session$destroy() and session$onDestroy() to clean up dangling reactivity when dynamic module UI is removed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add tests that verify weakref GC semantics work correctly with destroy callbacks for ReactiveVal, Observable, and Observer. Also fix two bugs discovered by the tests: 1. Inline closures in initialize() captured the entire enclosing environment (including `self`/`private`), preventing GC. Fixed by extracting to make_weak_destroy_wrapper() helper. 2. R's lazy evaluation meant the `wr` argument to the helper was a promise retaining a reference to initialize()'s execution env (which holds `self`). Fixed by adding force(wr). 3. Storing self$destroy as the weakref value created a strong reference cycle. Fixed by using wref_key() instead of wref_value() and calling obj$destroy() on the retrieved key. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Removes all keys matching a namespace prefix from the reactive values store, invalidates their dependents, and notifies names/list watchers. This enables cleanup of module inputs when a module scope is destroyed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…nySession Adds destroyCallbacksByNs (Map of namespace -> Callbacks) to both ShinySession and MockShinySession, with public onDestroy()/destroy() methods and private helpers for namespace-scoped callback invocation and resource cleanup (inputs, outputs, downloads, files). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ssion docs for onDestroy/destroy Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an explicit module-scope teardown API to Shiny sessions to eliminate “dangling reactivity” when dynamic module UI is removed, with reactive primitives participating automatically via weakly-registered destroy hooks.
Changes:
- Introduces
session$destroy()/session$onDestroy()for module session proxies, backed by namespace-keyed destroy callback infrastructure on the root session. - Adds destroy semantics/guards to core reactive primitives (
ReactiveVal,Observable,Observer) plusReactiveValues$_destroy()for namespace cleanup. - Adds comprehensive destroy-focused test coverage and documentation/NEWS updates.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
R/shiny.R |
Implements destroy callback registry on ShinySession, adds proxy onDestroy()/destroy(), and invokes destroy on websocket close. |
R/reactives.R |
Adds destroyed error condition + destroy methods and weak onDestroy registration for reactive primitives; adds ReactiveValues$_destroy(). |
R/mock-session.R |
Adds destroy callback infra and proxy destroy support for MockShinySession to enable testing. |
tests/testthat/test-destroy.R |
New unit tests covering destroyed guards, idempotency, weakref behavior, and module-scope teardown ordering. |
man/session.Rd |
Documents session$onDestroy() and session$destroy(). |
man/insertUI.Rd |
Documents how session$destroy() relates to removeUI() for module cleanup. |
R/insert-ui.R |
Adds roxygen section describing module cleanup with session$destroy(). |
man/MockShinySession.Rd |
Documents new MockShinySession$onDestroy() / $destroy(). |
NEWS.md |
Announces new session destroy APIs and behavior. |
.gitignore |
Ignores docs/ and .context. |
.Rbuildignore |
Excludes docs and .context from package builds. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Use ns.sep constant instead of hardcoded "-" in destroy logic - Sort root sentinel __root__ last during full-session teardown - Use rlang::names2() for NULL-safe output name lookup - Clean up output-related clientData entries (output_<ns>-* pattern) - MockShinySession: delegate close() to invokeDestroyCallbacks for proper deepest-first ordering and input cleanup - Always clean up inputs/outputs/routes even when no destroy callbacks are registered (remove early return before cleanup) - Rename misleading weakref test description - Add tests for close ordering, input cleanup, and root-last behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…I docs - Add "Module lifecycle and composability" section to ?session with data ownership examples (return-from-module pitfall vs pass-in pattern) - Add "Destroying module reactivity" section to ?moduleServer - Update ?removeUI to cross-reference ?session composability docs - Clarify destroy() invokes onDestroy callbacks, with cleanup as consequence Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Also, most people don't know about |
|
Do you view it as an anti-pattern to destroy a module from the parent session? My initial reaction is that this would be the more common pattern, which is why I think a more ergonomic API would make sense.
I see what you mean, but it also feels a little bit odd to me to motivate a harder-to-discover API based on this. The fact that we have a method for making scope also argues to me that it's sensible to one for the reverse operation. That said, maybe it is a little bit weird considering that |
|
After chatting, we think |
|
Hi! I'm really looking forward to this feature. Thanks for taking the time to implement it! Have you considered triggering This may not make sense if you're trying to keep the R and Python APIs aligned. And I can always write a wrapper that does this, so I'll be happy anyways :) |
Oooooo, thank you @mlechon ! I like the intent, but it feels too magical. I do agree that this is a nice place to raise an error and to tell the app author to call I'll go ahead and have it raise and error if any previous reactive objects exist when calling |
|
Am going to move the request to an Issue to separate it from this PR (and to give it it's own number) |
session$destroy() now accepts an optional `id`. When supplied, it is equivalent to session$makeScope(id)$destroy(), letting a parent tear down a module using the same id it used to insert the UI — no handle threading required. This works on the root session too, where session$destroy() with no id remains an error (root is closed via close()). The no-arg session$destroy() on a module proxy is unchanged: each scope still owns its own destroy method. Adds a validateDestroyId() guard and updates the ?session, ?moduleServer, and ?removeUI docs to lead with the parent-driven pattern, keeping the "return a handle" pattern as the secondary option. Resolves the API discussion with @cpsievert on #4372. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Drop "equivalent to session$makeScope(id)$destroy()" from NEWS and the ?session/?moduleServer/?removeUI prose; lead users to session$destroy(id) rather than teaching the makeScope() backdoor - Update the destroy() method item to document the optional id and add a removeUI() + destroy(id) example - Split the session "Module lifecycle and composability" section into "Destroying a module" and "Module data ownership" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Thank you all for your continued work on this!
I 100% agree with this -- I think the parent owning the child lifecycle is the most natural mental model. It's also the most general, I think: a child owning its own lifecycle only works in a subset of special cases. In fact, I might even go as far as to say it's an antipattern for the child to self-destroy / touch its own lifecycle, because in general you can leave the parent with dangling references. (But I'd need to stew / think deeper on this to say this confidently). So yeah, I think the
I think this is good. In the same spirit can we also error (or at least warn) if we |
Warnings for both would make sense. An error for currently "working" code is jarring. Need to see how duplicate output values are handled for a notification cadence. |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sweep the pre-existing \code{} usages in defineOutput() and showProgress()
inline docstrings to backticks for consistency.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
session$destroy(id) now emits a warning (class "shiny.destroy.unknown_id") when the id names a scope that was never created or has already been destroyed, helping catch typos and stale ids. It's a warning rather than an error so it doesn't break otherwise-working code, matching how Shiny treats duplicate output ids (always warn). Adds a private scopeExists() check to ShinySession and MockShinySession that runs before makeScope(id) (which would otherwise register its own callbacks and mask the check). Addresses @khusmann's request on #4372. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1d1da60 to
1cf4d10
Compare
|
Adding no warnings for both. Currently, the behavior for calling Removed the warning. |
callModule(server, character(0)) reaches makeScope(character(0)). #4372's new destroy-callback code branched on if (!nzchar(namespace)) to detect the root scope, but nzchar(character(0)) is logical(0), so the guard errored with "argument is of length zero". A length-0 namespace is documented NS() behavior meaning "no namespace" (root) and worked before #4372 (makeScope had no nzchar guard). Treat a length-0 namespace as root only in the destroy-key logic, leaving the namespace itself untouched so NS(character(0)) keeps returning ids unchanged (normalizing to "" would instead prefix ids with a stray separator).
callModule(server, character(0)) reaches makeScope(character(0)). #4372's new destroy-callback code branched on if (!nzchar(namespace)) to detect the root scope, but nzchar(character(0)) is logical(0), so the guard errored with "argument is of length zero". A length-0 namespace is documented NS() behavior meaning "no namespace" (root) and worked before #4372 (makeScope had no nzchar guard). Treat a length-0 namespace as root only in the destroy-key logic, leaving the namespace itself untouched so NS(character(0)) keeps returning ids unchanged (normalizing to "" would instead prefix ids with a stray separator).
callModule(server, character(0)) reaches makeScope(character(0)). #4372's new destroy-callback code branched on if (!nzchar(namespace)) to detect the root scope, but nzchar(character(0)) is logical(0), so the guard errored with "argument is of length zero". A length-0 namespace is documented NS() behavior meaning "no namespace" (root) and worked before #4372 (makeScope had no nzchar guard). Treat a length-0 namespace as root only in the destroy-key logic, leaving the namespace itself untouched so NS(character(0)) keeps returning ids unchanged (normalizing to "" would instead prefix ids with a stray separator).
Default the public destroy()/onDestroy() entry points and the internal invokeDestroyCallbacks() to NULL instead of character(0), and lead with NULL in the docstrings and guard messages. NULL is the idiomatic R 'absence' value and was the original #4372 public default. character(0) remains accepted (both are length 0, which is how root is detected everywhere), so callers like teal that pass character(0) are unaffected; '' and NA are still rejected.
Summary
Port of posit-dev/py-shiny#2209 — adds
session$destroy()andsession$onDestroy()to R Shiny.session$destroy()on module session proxies to destroy all reactive state (values, expressions, observers, inputs, outputs) for a module scope and its descendantssession$onDestroy(callback)to register cleanup callbacks that fire on destroyReactiveVal,Observable,Observer,ReactiveValues) auto-register via weak references during init, so destruction is automatic and comprehensiveReactiveValuesgains a fulldestroy()method with domain auto-registration, destroyed guards onget()/set(), and adestroyByPrefix()method for namespace-scoped cleanupinvalidateLater()timers are cancelled on both session end and module destroy, with cross-deregistration to prevent leaksonDestroyregistration respects theautoDestroyflagshiny.destroyed.errorcreateMockDomain()supportsonDestroy/destroylifecycle (destroy fires onend())..rootis rejected inmakeScope()registerBookmarkExclude()returns an unsubscribe handle, cleaned up on module destroyCloses #2281 — The canonical issue:
insertUI()/callModule()to add,removeUI()/??? to remove. No mechanism to deactivate a module server instance.Closes #825 — "Reactive subDomains" proposal (2016). Proposes
createSubDomain()where ending the subdomain destroys all reactive objects created within it.Related to #2374 — Request to delete server-side input values on
removeUI(). Fix is to wrap the UI component into a module. Then removal of all reactivity (not just the input) becomes:mod_session$destroy().What this means for app authors
When a module's UI is actually removed from the DOM — via
removeUI(), or by re-rendering auiOutput/renderUIso the old UI is replaced — only the UI goes away. The module's server-side reactivity — its observers, reactive values, and reactive expressions — keeps running invisibly in the background. In a long-running app that adds and removes modules repeatedly (tabs, list items, editor panes), this "dangling reactivity" accumulates: observers keep firing,invalidateLater()timers keep ticking, and memory grows over the life of the session.This PR gives you a way to clean all of that up. The parent owns the child's lifecycle: it created the module under an
id, it removes that module's UI, and it tears down the module's reactivity under the sameid— no need to thread a cleanup handle back out of the module:session$destroy(id)tears down every reactive object created in that module's scope (and any nested child modules), and runs any cleanup you registered withsession$onDestroy(). Crucially, it's scoped: the parent session and sibling modules are untouched, so it's safe to destroy one module without affecting the rest of the app.One rule to keep in mind: data that must outlive a module should be created outside it. A
reactiveVal()created inside a module is destroyed with the module, so reading it afterward errors. If you need a result to survive, create the reactive value in the caller and pass it into the module — it lives in the caller's scope and remains valid after the module is gone.Key design decisions
rlang::new_weakref()allow reactive objects to be GC'd before explicitdestroy()— matching py-shiny semanticsShinySession$destroy()throws an error — only module session proxies can be destroyed; root session usesclose()Mapkeyed by namespace stringmake_weak_destroy_wrapper()helper avoids closure environment leaks (discovered via GC tests)ReactiveValuesdomain auto-registration — user-createdreactiveValues()inside a reactive domain auto-register for destroy (likereactiveVal); session-level stores (input/clientData) usedestroyByPrefix()for namespace-scoped cleanup insteadinvalidateLater()—onEndedandonDestroycallbacks each deregister the other when they fire, preventing double-cleanup and leaked handlesFiles changed
R/reactives.RdestroyedReactiveError,ReactiveVal$destroy(),Observable$destroy(), Observer weak registration withautoDestroygating,ReactiveValues$destroy()+destroyByPrefix()+ domain auto-registration,make_weak_destroy_wrapper()R/shiny.RShinySessiondestroy infrastructure,makeScope()proxy overrides,wsClosed()integration,registerBookmarkExclude()unsubscribe handle,..rootnamespace guardR/reactive-domains.RcreateMockDomain()withonDestroy/destroysupportR/mock-session.RMockShinySessiondestroy support, bookmark-exclude real implementations,..rootnamespace guardR/insert-ui.R@sectiononremoveUIfor destroy usageR/modules.R@sectiononmoduleServerfor destroy usage, realistic docs exampletests/testthat/test-destroy.Rtests/testthat/test-mock-session.Rtests/testthat/test-test-server.R🤖 Generated with Claude Code